Repository navigation
Conversation
…gnals windows in one tree Dated log lines kept their literal date in the template, and emerging_signals mined the baseline and current windows in separate trees and matched them by template text, so the same line showed up as both new and gone. Mask timestamps (ISO-8601/RFC-3339, RFC-1123, access-log, syslog), UUIDs and long hex ids by default, mine both windows with one TemplateMiner and compare by cluster id. End the baseline 1 ms early so a row at the shared boundary isn't counted in both windows. Refs ClickHouse#3544.
🦋 Changeset detectedLatest commit: 3eb9ffc The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
@bcanvural is attempting to deploy a commit to the HyperDX Team on Vercel. A member of the Team first needs to authorize it. |
| miner: new TemplateMiner(new TemplateMinerConfig()), | ||
| }; | ||
|
|
||
| const [curRes, baseRes] = await Promise.all([ |
There was a problem hiding this comment.
🟠 major — The shared Drain tree is filled in whichever order the two ClickHouse queries finish, so emerging_signals output can differ between runs
mineWindowPatterns mines synchronously right after its own queries resolve (runEventPatterns.ts:237). With Promise.all, whichever window's queries return first goes into the shared miner first. Drain depends on insertion order: clusters, ids and templates come out differently, so identical inputs can give different emerging/disappeared lists. Also, when the current window is mined first, its pattern (taken first in ingest) is the cluster's template before the baseline rows generalized it. Fix: fetch both windows in parallel, then mine in a fixed order (baseline first, then current). One way is to split fetching from mining in mineWindowPatterns; the simpler way is to run the two calls one after the other. Then read each cluster's template from the miner once both windows are in.
Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond
| input.sourceId, | ||
| base.startDate, | ||
| base.endDate, | ||
| new Date(base.endDate.getTime() - 1), |
There was a problem hiding this comment.
🔵 minor — Ending the baseline 1 ms early silently drops rows with sub-millisecond timestamps in that last millisecond
Source timestamps are usually DateTime64(9). Rows in (baselineEnd − 1 ms, baselineEnd) now fall outside the baseline (<= end−1ms) and outside the current window (>= currentStart). The summary still reports base.endDate as the end. Fix: keep base.endDate and pass an exclusive end through to the window query instead. The chart config already supports this as dateRangeEndInclusive: false (packages/common-utils/src/types.ts:2056, honored in renderChartConfig.ts:1062), so add a dateRangeEndInclusive option to mineWindowPatterns and set it on the baseline sample and count configs.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| maskPrefix: string = '<'; | ||
| maskSuffix: string = '>'; | ||
| maskingInstructions: MaskingInstructionConfig[] = []; | ||
| maskingInstructions: MaskingInstructionConfig[] = [ |
There was a problem hiding this comment.
🔵 minor — The web app's Event patterns and denoise don't get the new masks, so the changeset's 'Event patterns no longer split' claim is wrong there
The UI's Event patterns and the DBRowTable denoise run Python drain3 through Pyodide with their own config (packages/app/src/hooks/usePatterns.tsx:62-63), so DEFAULT_MASKING_INSTRUCTIONS never reaches them. Dated lines still split in the UI, and web denoise now groups rows differently from MCP denoise, even though mine-patterns.ts says the denoise constants are shared between the two paths. Fix: either pass the same masking instructions into the Pyodide TemplateMinerConfig, or limit the changeset wording to the MCP/CLI paths.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
PR Review
1 finding(s): 🔴 0 critical · 🟠 0 major · 🔵 1 minor 1 posted as inline comment(s) on the changed lines. Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides. |
Deep Review✅ No critical issues found. This is a well-scoped, well-tested change. The default date-masking instructions, the 🟡 P2 -- recommended
🔵 P3 nitpicks (2)
Reviewers (7): correctness, adversarial, testing, maintainability, performance, kieran-typescript, previous-comments. Testing gaps:
No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond |
…pen baseline, drop masked tokens from whereSnippet Fetch both windows in parallel, then mine baseline and current into the shared miner in that order, so the result doesn't depend on which query finishes first; report each cluster's template from the finished tree. End the baseline with an exclusive bound instead of 1 ms early. Drop tokens that contain a mask from whereSnippet. Narrow the changeset to the MCP/CLI paths (the web app's Event patterns view uses drain3 with its own config).
|
Thanks for the reviews. Addressed in 8c44344:
Tests: 489 api MCP unit tests, |
| * pattern → search. Tokens that contain a mask (`<*>`, also inside a token such | ||
| * as `[<*>]` or `trace_id=<*>`) are dropped, since their text varies per line. | ||
| * Escapes \ and " (the phrase-query metachars) in each token. | ||
| */ |
There was a problem hiding this comment.
🔵 minor — whereSnippet joins non-adjacent tokens into a field phrase, which compiles to an exact match on Body
A quoted Body:"..." term goes through serializer.eq (packages/common-utils/src/queryParser.ts:2076), so ClickHouse gets Body = 'task finished in ms'. That never matches a line whose masked tokens were dropped from between the words. The masks make this the common case: most dated lines now produce a masked token. Have the snippet emit one term per literal token, e.g. Body:*task* Body:*finished*, or use implicit-field substring terms. Then change the new whereSnippet.test.ts so it stops pinning the output that cannot match.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| ingest(baseRes, 'base'); | ||
| // A cluster's template can still generalize after a window first sees | ||
| // it, so report each cluster's template from the finished tree. | ||
| for (const a of byKey.values()) { |
There was a problem hiding this comment.
🔵 minor — Reported template comes from re-matching the sample, not from the cluster the row is keyed by
byKey is keyed by cluster id, but miner.match(sample, 'fallback') searches the tree again. That search can land on a different cluster: a literal child added later can divert the tree walk, and the fastMatch tie-break prefers the candidate with more params. Look the template up by id instead. One way: expose a getCluster(id) on Drain (it already has a private clusterPeek) and read getCluster(Number(key)).getTemplate().
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
…, UUID and hex masks \b doesn't match between _ and a digit, so backup_2026-11-04 and span_<hex> kept their dates and ids. Use alphanumeric lookarounds instead.
|
Round 2:
That's two automated rounds, so per AGENTS.md I'll stop here. @brandon-pereira, ready for a look when you have time. |
| // Mine in a fixed order (baseline, then current): Drain depends on | ||
| // insertion order, so this keeps the result independent of which query | ||
| // finished first. | ||
| const miner = new TemplateMiner(new TemplateMinerConfig()); |
There was a problem hiding this comment.
🟠 major — With one Drain tree, a new message that resembles a baseline message no longer counts as emerging
Drain merges any same-length line with ≥40% token overlap (drainSimTh 0.4) into the existing cluster. Example: the baseline has user login failed: invalid password and the current window adds user login failed: account locked. Both now land in the baseline's cluster (user login failed: <*> <*>), so the new error shows up as at most 'shifted' and usually not at all. Before this change, separate trees gave them different templates and the new one was reported as 'new'. Fix: detect generalization caused by the current window. For example, record addLogMessage().changeType === 'cluster_template_changed' for current-window rows on clusters first created by the baseline, or also compare the baseline-only template against the final one, and surface those clusters. Add a test with same-length, mostly overlapping messages; the existing new/gone tests only use lines with different token counts.
Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond
| // A cluster's template can still generalize after a window first sees | ||
| // it, so report each cluster's template from the finished tree. | ||
| for (const a of byKey.values()) { | ||
| a.pattern = |
There was a problem hiding this comment.
🔵 minor — Re-reading the template with miner.match(sample) can return a different cluster than a's id
Drain.match (packages/common-utils/src/drain/drain.ts:332) returns the 1.0-similarity cluster with the most params, and with 'fallback' it searches every cluster of that length. So a more general cluster can win, and two different Aggs can report the same pattern string. The cluster id (byKey key) is already known, so look the cluster up by id instead: expose a public getCluster(id) on Drain/TemplateMiner that wraps clusterPeek, and use getTemplate() on the result.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
| expect(all).toEqual( | ||
| expect.arrayContaining([ | ||
| expect.stringContaining('disk almost full'), | ||
| expect.stringContaining('cache warmed'), |
There was a problem hiding this comment.
🔵 minor — Order-independence test would still pass if mining followed fetch completion order
The baseline and current fixtures have no overlapping clusters that generalize differently depending on which window goes first, and final templates are re-read from the finished tree. So mining each window inside its fetch's .then (completion order) would give an identical result. Use fixtures where insertion order changes the outcome, e.g. one window holds the constant host=db-1 lines and the other the varying ones, with the varying set first. Assert that the output matches the baseline-first result.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
…n as new_variant With one Drain tree, a current-window message similar to a baseline one (same length, enough shared tokens) is merged into the baseline cluster and only generalizes its template, so it was no longer reported as emerging. Snapshot each baseline template before mining the current window; where the current window changed it, the current rows that fit the new template but not the old one are reported as status new_variant, with the old template and a sample. Read each cluster's final template by id (Drain.getCluster) instead of re-matching the sample.
|
One more round, out of the usual two, because the latest review found a regression this PR introduced: with one Drain tree, a new message that resembles a baseline one (e.g.
Replied rather than changed: the I'll stop here. @brandon-pereira, ready for a look when you have time. |
| status: | ||
| a.baseShare === 0 ? 'new' : a.curShare === 0 ? 'gone' : 'shifted', | ||
| sample: a.sample.slice(0, 300), | ||
| status: a.variant |
There was a problem hiding this comment.
🔵 minor — Entries in disappeared can carry status: "new_variant"
a.variant is set whenever any current row fits the widened template. When variant.share is below newPatternShareFloor, the verdict falls back to classifyShift, which can return disappeared (the baseline share dropped ≥ minShareRatio×). fmt still labels the entry new_variant, with baselinePattern/variantShare, inside the disappeared list, which contradicts the usage text. Fix: set new_variant (and the extra fields) only when the variant itself drove the emerging verdict. For example, clear a.variant when share < newPatternShareFloor, or base the status on the verdict.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
…oves to its own PR Review showed that comparing both windows in one Drain tree needs new-variant detection of its own, so it moves to a follow-up. This PR keeps the default masks (now also ctime/Java, log4j, Spark, klog and Go dates; drain3 JSON configs without masks get none), the whereSnippet fix for tokens that contain a mask, and their tests. emerging_signals goes back to two separate trees.
|
I've narrowed this PR to masking (option 1 of #3544): the default masks, the Diff against main is now 5 files, +225/−12. Tests: 3,065 common-utils, 486 MCP unit, and the emerging_signals + pattern/denoise integration tests pass. |
|
On the remaining 🟠 (the The deep-review P2s (a |
Take ClickHouse#3426's drill-down predicate in runEventPatterns.ts; drop this PR's buildWhereSnippet and its test, which ClickHouse#3426 supersedes.
…n the drill-down regex ClickHouse#3426's predicate matches a whole-token <*> as exactly one word and escapes anything else literally, so a masked timestamp that spans spaces (2026-11-04 06:12:12,345) or a mask inside a token ([<*>], trace_id=<*>) matched nothing. Named masks keep Drain's clustering as it is (a mask is one token that is equal across lines), and the regex maps <TS> to any text and <ID> to one word, also inside a token. Whole-token <*> is unchanged.
|
Merged
New tests mine dated Java, access-log, |
| ? escapeRe2Literal(piece) | ||
| : piece === TIMESTAMP_TOKEN | ||
| ? '.+?' | ||
| : `${NON_WS}+`, |
There was a problem hiding this comment.
🟠 major — A <*> that Drain merged over a masked timestamp only matches one word, so the drill-down misses the cluster's own samples
Masking runs before Drain, so Drain can merge <TS> with a different token in the same position. Example: Started job at 2026-11-04 06:12:12 (masked to Started job at <TS>) and Started job at boot share a leaf, and the template becomes Started job at <*>. The whole-token <*> maps to NON_WS+, so the raw sample (5 words) fails the predicate. That breaks the 'every sampled member matches' promise in PATTERN_MATCH_SEMANTICS, which held before because unmasked dates were separate tokens. Fix: either stop Drain from generalizing a mask token into <*>, or let a whole-token <*> also match the timestamp shapes (e.g. (?:NON_WS+|.+?)). Add a test that mines a <TS> line together with a non-timestamp line.
Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond
| @@ -0,0 +1,12 @@ | |||
| --- | |||
| '@hyperdx/common-utils': patch | |||
| '@hyperdx/api': patch | |||
There was a problem hiding this comment.
🔵 minor — Changeset does not bump @hyperdx/cli even though the CLI's behaviour changes
packages/cli bundles @hyperdx/common-utils (it is a devDependency; see packages/cli/package.json and TemplateMinerConfig at packages/cli/src/cli.tsx:2302, plus minePatterns in usePatternData.ts). The changeset text says 'and the CLI', but only common-utils and api are released. Add '@hyperdx/cli': patch so the CLI change ships.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Mask the date parts of a timestamp to <DATE> and leave times and ids to Drain, so a masked token compares like the date it replaced: equal within a day, equal across days. Fixes the drill-down for a <*> merged over a masked timestamp, and denoise now masks bodies like the miner does.
| for (const mi of instructions) { | ||
| allowedPatterns.push(mi.pattern); | ||
| // A per-word mask stands for one word of its pattern's match. | ||
| allowedPatterns.push(mi.perWord ? '\\S+' : mi.pattern); |
There was a problem hiding this comment.
🔵 minor — Adding \S+ for per-word masks turns off exact matching for every <DATE> mask, not only the per-word ones
All seven default instructions use the same mask name DATE, so allowedPatterns becomes iso|\S+|year|.... Because \S+ is one of the alternatives, every <DATE> placeholder accepts any word. For example, extractParameters('wrote backup_<DATE>.tar', 'wrote backup_hello.tar') with exactMatching=true now returns ['hello'] when it should return null. Fix: give the per-word instructions their own mask name, or build a per-word alternative that only matches one word of that instruction's own pattern (e.g. a month or weekday name, a day, or a year) rather than a bare \S+. Nothing in production calls extractParameters today; only tests use it.
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
There was a problem hiding this comment.
Leaving this as is: nothing in production calls extractParameters, and matching each per-word slot exactly would need a second pattern per instruction. \S+ still keeps each <DATE> to one word.
|
The latest commit masks only dates: the date parts of a timestamp become Why not times and ids: they already differ from line to line, so Drain turns them into
What's left: a line seen only once keeps its id or time in its template, so it can still show as new and gone.
One change within a day: klog lines with the same message but different severity letters are no longer merged, because On the review of this commit:
@brandon-pereira, ready for a look when you have time. |
|
Moving this to draft to stay within the open-PR limit and make room for the fix for #3580 (PromQL dashboard variables fail on ClickHouse 26.10), which is more urgent for us. I'll mark this ready again when a slot frees. |
Refs #3544 (option 1)
Option 2 (comparing both windows in one Drain tree) moved to #3556 / Draft #3557, stacked on this PR. Option 3 (
bodyExpression) is left for a new issue after #3426, as agreed on #3544. Merged withmainafter #3426 landed: the drill-down uses #3426's predicate.What
Default masking before Drain (
packages/common-utils/src/drain/config.ts).TemplateMinerConfigstarts withDEFAULT_MASKING_INSTRUCTIONS, which mask only the date of a timestamp to<DATE>(exported asDATE_MASK): ISO dates (also beforeTin ISO-8601), RFC-1123, ctime and JavaDate.toString, JavaScriptDate.toString,java.util.logging, syslog, access log, Tomcat,dd/MM/yyyy,MM/dd/yyyy,dd.MM.yyyy, Sparkyy/MM/ddand the klogI1104date. Forms other than ISO dates are masked only before a time.Times and ids are not masked. Every line of a day shares its date, and
<DATE>matches itself the same way, so within one day lines group as without masks (tested); across days the templates are the same. One change within a day: klog lines with the same message but different severity letters are no longer merged, becauseI<DATE>andE<DATE>route by their letter.No mask changes a line's token count: month-name dates are masked word by word through a new optional
perWordflag on masking instructions (default off, as in drain3). Every caller that builds aTemplateMinerConfig(pattern mining, denoise, the CLI) gets the masks;TemplateMinerConfig.fromJSONapplies none unless the JSON lists them, as drain3 does.Drill-down (
templateToRegexinpatternDrilldown.ts):<DATE>matches one or more non-whitespace characters, also inside a token (backup_<DATE>.tar). A whole-token<*>is unchanged, so fix(mcp): make event_patterns drill-down snippets executable #3426's matching rules and tests stay.Denoise (
buildNoisyBodyMatcher): masks each body the same way before comparing it with the noisy templates.The web app's Event patterns view runs drain3 in Pyodide with its own config and is unchanged.
Why
On dated logs (Java/Logback, Python, Airflow run ids),
emerging_signalswith a yesterday baseline reported the same pattern as both emerging and disappeared, because the templates differed only by the date (#3544: 19–135 false patterns per run on 4 services).Tests
drain-masking.test.ts: each mask on real-looking lines; lines left alone (versions, hex, short shas, times, ids,5 Mar,10.11.2026without a time,1234-56-78); a mixed day of logs clusters the same with and without masks (klog has its own test); two days and a month boundary give the same templates; word counts never change;extractParametersreads a per-word date back; drain3fromJSONbehaviour; linear time on a 128 KiB whitespace run.patternDrilldown.test.ts:<DATE>as a word and inside a token, mined dated lines whose predicate matches every own sample, and a dated line next toStarted job at boot.denoise.test.ts: dated noisy lines are removed and a rare line is kept.